feat(criteria): pass_context for run_command + node_modules/.bin PATH hardening#35
feat(criteria): pass_context for run_command + node_modules/.bin PATH hardening#35uipreliga wants to merge 1 commit into
Conversation
Opt-in pass_context: true on the run_command criterion serializes the in-flight EvaluationResult (task.json schema) to a temp file and points the scoring command at it via $CODER_EVAL_CONTEXT, giving scoring scripts the agent trajectory and resolved config — not just the filesystem. Script authors can develop offline against a task.json from a previous run, then wire the same script in unchanged. Implementation: - New extra_env passthrough on Sandbox.run_command (last-wins), guarded so it cannot clobber the sandbox-isolation floor (PATH / VIRTUAL_ENV / NODE_PATH / NPM_CONFIG_PREFIX). Also corrects the stale Raises: TimeoutExpired docstring (run_command returns (-1,"",msg)). - pass_context threads Orchestrator -> SuccessChecker -> CheckContext (new run_result field) -> RunCommandChecker; _check_impl's signature is unchanged (state rides on CheckContext). _serialize_context clears success_criteria_results via a non-mutating model_copy so scripts can't depend on list position or leak prior-turn verdicts; the temp dir is deleted on every exit path incl. the timeout return. Missing run_result fails closed to score=0.0. - Security: node_modules/.bin (agent-writable) is now APPENDED, not prepended, to PATH so a planted interpreter shim (python/node/sh/uv) cannot hijack scoring commands. Docs (TASK_DEFINITION_GUIDE + CLAUDE.md) updated; criterion count stays 14. Full suite green, 90.91% coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
I'll analyze this and get back to you. |
|
|
||
| if TYPE_CHECKING: | ||
| from coder_eval.models.results import TurnRecord | ||
| from coder_eval.models.results import EvaluationResult, TurnRecord |
akshaylive
left a comment
There was a problem hiding this comment.
IMO we should completely decouple eval from the runs. This means, we should write run results to a specific file before invoking the success criteria. That way, custom criteria can look at everything. I.e; the folder structures can be standardized.
uipreliga
left a comment
There was a problem hiding this comment.
Review: coder_eval — pr:35
Scope: pr:35 · branch feat/run-command-pass-context · ba600b6 · 2026-07-23T04:00Z · workflow variant
Change class: complex — adds a pass_context data-exposure path (serializes the in-flight EvaluationResult / task.json schema to $CODER_EVAL_CONTEXT for scoring scripts) plus sandbox PATH hardening (node_modules/.bin); security-sensitive env/PATH handling and new criterion-field semantics require correctness reasoning.
Clean, well-tested feature work — pass_context for run_command plus sandbox node_modules/.bin PATH hardening — with zero blockers (no 🔴/🟠) across all 8 axes. The one substantive issue is a 🟡 reproducibility bug: in simulation dialog (every_turn/both) mode the pass_context payload leaks the previous turn's real weighted_score because _serialize_context scrubs only success_criteria_results, breaking the documented "weighted_score is null — do not read" contract; a doc/model mismatch (duration_seconds documented as null but a non-nullable float defaulting to 0.0) and a few test-coverage gaps round out the list. Overall 9.9/10; weakest axis Evaluation Harness Quality at 9.4/10.
Summary
| Axis | Score | 🔴 | 🟠 | 🟡 | 🔵 | Top Issue |
|---|---|---|---|---|---|---|
| 1. Code Quality & Style | 9.9 / 10 | 0 | 0 | 0 | 1 | Resolved EvaluationResult named result, forcing awkward r loop variable in check_all |
| 2. Type Safety | 10 / 10 | 0 | 0 | 0 | 0 | — |
| 3. Test Health | 9.8 / 10 | 0 | 0 | 0 | 2 | Orchestrator wiring of pass_context (run_result=self.result) has no direct test |
| 4. Security | 10 / 10 | 0 | 0 | 0 | 0 | — |
| 5. Architecture & Design | 10 / 10 | 0 | 0 | 0 | 0 | — |
| 6. Error Handling & Resilience | 10 / 10 | 0 | 0 | 0 | 0 | — |
| 7. API Surface & Maintainability | 10 / 10 | 0 | 0 | 0 | 0 | — |
| 8. Evaluation Harness Quality | 9.4 / 10 | 0 | 0 | 1 | 1 | pass_context scrub leaks stale prior-turn weighted_score in simulation dialog mode |
Overall Score: 9.9 / 10 · Weakest Axis: Evaluation Harness Quality at 9.4 / 10
Totals: 🔴 0 · 🟠 0 · 🟡 1 · 🔵 4 across 8 axes.
Blockers
None.
Non-blocking, but please consider before merge
- [Axis 8] pass_context scrub leaks stale prior-turn weighted_score in simulation dialog mode (
src/coder_eval/criteria/run_command.py:35) —_serialize_contextscrubs only one provisional field:return run_result.model_copy(update={"success_criteria_results": []}).model_dump_json(). The docstring and thepass_contextfield docs both promise thatweighted_scoreisnulland "do not read" it. That holds on the single-shot path (calculate_weighted_score runs AFTER check_all), but in simulationevery_turn/bothmode_run_dialog_criteria_check(orchestrator.py) callsself.result.calculate_weighted_score(...)at the end of EVERY turn, so at the next turn's checkrun_result.weighted_scorecarries the PREVIOUS turn's real score. A pass_context scoring script that readsweighted_scorein dialog mode therefore gets a stale, non-null prior-turn value — a non-reproducible score for identical current-turn agent output. The scrub is inconsistent with itself: the exact "still holds the previous turn's results" reasoning cited to clearsuccess_criteria_resultsapplies equally to its derivedweighted_score. Fix: extend theupdate=dict to also reset the post-check provisional fields (weighted_score=None,completed_at=None,duration_seconds=0.0) so the serialized payload matches its documented contract in all run modes.
Nits
- [Axis 1] Resolved EvaluationResult named
result, forcing awkwardrloop variable in check_all (src/coder_eval/evaluation/checker.py:150) — In bothcheck(line 150:result = run_result if run_result is not None else self._run_result) andcheck_all(line 188: same assignment), the resolved in-flightEvaluationResultis bound to a local namedresult. Incheck_allthis collides with the codebase's convention of naming aCriterionResultresult, forcing the per-iteration result to be renamed to the terser(line 191:r = self._check_single(...); results.append(r)), which reads worse than the pre-PRresult. Name the resolved valuerun_result(orrun_result_val) to keep the loop variable as the conventionalresultand make it obvious the local is an EvaluationResult, not a CriterionResult. Purely cosmetic; no behavior change. - [Axis 3] Orchestrator wiring of pass_context (run_result=self.result) has no direct test (
src/coder_eval/orchestrator.py:1355) — The three production seams that actually make pass_context work — orchestrator.py:1355, :1415, :1509, eachrun_result=self.result,passed intochecker.check_all(...)— are exercised by no test. Every pass_context test drivesSuccessChecker.check/check_alldirectly (tests/test_pass_context.py, tests/test_run_command_stdout.py::TestPassContextChecker), so if a future edit droppedrun_result=self.resultfrom any of the three orchestrator call sites, everypass_context=truecriterion would silently raise ValueError and score 0.0 in production with no failing test. Add one orchestrator-level test (e.g. runcheck_successwith a pass_context run_command criterion and assert the script saw a populated context) so the wiring seam is guarded. Severity kept Low because the downstream contract is very well covered one call away; only the trivial attribute-passing is untested. - [Axis 3] Payload 'placeholder/null' field contract asserted for success_criteria_results only, not the other documented fields (
tests/test_run_command_stdout.py:440) — Thepass_contextField docstring (models/criteria.py:229-236) makes an explicit contract that scripts rely on:weighted_score / completed_at / duration_seconds are null and final_status is still a placeholder ... do not read them. Onlysuccess_criteria_results == []is asserted (test_payload_success_criteria_results_always_empty:458 and the round-trip test at line 440, which checksrehydrated.task_id == 'test'andrehydrated.iterations == []). No test asserts the payload'sweighted_score/completed_at/duration_secondsare None — so a future change that finalized those before the criteria phase would silently expose values scripts were told to ignore, with no failing assertion. Add an assertion in the round-trip test that these fields are null in the serialized payload. - [Axis 8] pass_context docs claim duration_seconds is null but it defaults to 0.0 (non-nullable float) (
src/coder_eval/models/criteria.py:236) — Thepass_contextfield description states "weighted_score / completed_at / duration_seconds are null" (criteria.py:235-236), and docs/TASK_DEFINITION_GUIDE.md repeats "weighted_score,completed_at, andduration_secondsarenull". ButEvaluationResult.duration_secondsis declaredduration_seconds: float = Field(default=0.0, ...)(models/results.py:446) — a non-nullable float that is0.0at check time, nevernull. A script author who trusts the doc (e.g.if ctx["duration_seconds"] is None) will misread the payload. Fix the field description and the guide to sayduration_secondsis0.0(not finalized) rather thannull.
What's Missing
Daily/nightly:
- 🟠 The PATH change (prepend -> append of /node_modules/.bin in _build_run_command_env) alters binary resolution on the production/nightly scoring run path: for identical agent output, any run_command whose command name collides with a binary present in node_modules/.bin (e.g. a package-local tsc/eslint or an agent-planted shim) previously resolved node_modules first and now resolves the venv/host binary. The PR does not state nightly blast radius — it can flip a task's score under identical agent output. Confirm no nightly task relies on node_modules/.bin shadowing and state the impact explicitly. (trigger: src/coder_eval/sandbox.py)
- 🔵 The PR does not state that pass_context is opt-in (default false) and that the serialized context is task.json-shaped with NO new persisted field, so the cross-repo contract (task.json schema / report JSON consumed by the external coder-eval-uipath eval-runner) is unchanged. This is the reassuring half of the blast-radius answer and should be recorded so a reviewer isn't left to infer it. (trigger: src/coder_eval/models/criteria.py)
Parallel paths:
- 🟡 _serialize_context was written for the single-shot path (where calculate_weighted_score runs AFTER check_all) but the parallel simulation every_turn/both path (_run_dialog_criteria_check calls calculate_weighted_score at the end of every turn) was not accounted for — the scrub clears success_criteria_results but not the per-turn-recomputed weighted_score, so the dialog path diverges from single-shot and leaks a stale prior-turn score. The scrub needed updating for the parallel path and wasn't. (trigger: src/coder_eval/criteria/run_command.py) (restates: Axis 8: pass_context scrub leaks stale prior-turn weighted_score in simulation dialog mode)
Tests:
- 🟡 No test exercises pass_context in simulation/dialog every_turn mode — the exact path where the stale-weighted_score leak lives. test_pass_context.py has only single-shot/direct-checker tests, so the dialog divergence ships unguarded. Add a dialog-mode test that asserts the serialized context's weighted_score is null at turn N+1 regardless of turn N's score. (trigger: tests/test_pass_context.py) (restates: Axis 8: pass_context scrub leaks stale prior-turn weighted_score in simulation dialog mode)
- 🔵 The three orchestrator seams that actually wire pass_context (run_result=self.result at orchestrator.py:1355/1415/1509) have no direct test — every pass_context test drives SuccessChecker.check/check_all directly, so dropping run_result=self.result from any call site would silently make every pass_context criterion raise ValueError and score 0.0 in production with no failing test. Add one orchestrator-level test guarding the wiring seam. (trigger: src/coder_eval/orchestrator.py) (restates: Axis 3: Orchestrator wiring of pass_context (run_result=self.result) has no direct test)
- 🔵 The payload null/placeholder contract promised in the pass_context field docs (weighted_score / completed_at / duration_seconds provisional, final_status a placeholder, do not read them) is only asserted for success_criteria_results == []. A future change finalizing the other documented fields before the criteria phase would expose values scripts were told to ignore with no failing assertion. Add round-trip assertions covering the other documented fields. (trigger: tests/test_run_command_stdout.py) (restates: Axis 3: Payload placeholder/null field contract asserted for success_criteria_results only)
Downstream consumers:
- 🔵 Downstream scoring-script authors consuming the context JSON are told duration_seconds is null, but EvaluationResult.duration_seconds is a non-nullable float defaulting to 0.0 — a script trusting the doc (e.g.
if ctx['duration_seconds'] is None) misreads the payload. The field description and docs/TASK_DEFINITION_GUIDE.md should say 0.0 (not finalized) rather than null. (trigger: docs/TASK_DEFINITION_GUIDE.md) (restates: Axis 7/8: pass_context docs claim duration_seconds is null but it defaults to 0.0)
Harness & Lint Improvements
Static checks (lint / type):
- [ce-lint] Add CE026 (next free number, wired into tests/lint/rules/ + tests/lint/runner.py) enforcing the pass_context 'provisional field' contract from a single source of truth: define one canonical PROVISIONAL_FIELDS constant listing every EvaluationResult field not finalized until after the criteria phase (weighted_score, completed_at, duration_seconds, final_status, success_criteria_results), and forbid _serialize_context (criteria/run_command.py:35) from calling model_copy(update={...}) with an update-dict whose keys are not exactly that set. This is the same 'two sites must stay consistent' shape as CE025 (live_stop_polarities <-> live_verdict). Root cause of both A8-medium and A7/A8-low is that the scrub subset and the documented null-list are hand-maintained in two places and drifted; a rule anchoring the scrub to the constant makes an omitted field (weighted_score) a mechanical lint failure. Prevents: A8-medium (stale weighted_score leaks in dialog mode because the scrub omits it) and, once the docstring is generated/checked against the same constant, the A7/A8-low doc-vs-model mismatch (duration_seconds documented as null).
- [pyright] Tighten the documented contract into the type system where possible: the A7/A8-low finding is that duration_seconds is declared
float = 0.0(non-nullable) yet documented as 'null'. Rather than a doc-only fix, consider the scrub resetting it to a sentinel that matches the type (0.0), and add a@override/typed helper so the placeholder-payload shape is a named typed object rather than an ad-hoc model_copy — making the 'these fields are provisional' invariant visible to pyright instead of living only in a prose docstring. Prevents: A7/A8-low (duration_seconds documented as null but is a non-nullable float defaulting to 0.0).
Harness improvements (not statically reachable):
- Add a golden/contract test that runs the real orchestrator through a pass_context run_command criterion in BOTH single-shot and simulation every_turn/both modes, deserializes the payload the scoring script actually saw at $CODER_EVAL_CONTEXT, and asserts every field of the documented contract: success_criteria_results == [], weighted_score is None, completed_at is None, duration_seconds == 0.0, final_status is the placeholder. The dialog variant must run at least two turns so turn N's calculate_weighted_score has fired before turn N+1's check. Why not static: The A8-medium leak only manifests at runtime: it depends on orchestrator._run_dialog_criteria_check calling calculate_weighted_score at the end of turn N, mutating shared state that a static reader of _serialize_context cannot see. A static check can enforce the scrub-set shape but not that the mutation actually leaked mid-dialog. Prevents: A8-medium (stale prior-turn weighted_score), A3-low (only success_criteria_results asserted, not weighted_score/completed_at/duration_seconds), and A7/A8-low (duration_seconds actual value).
- Add one orchestrator-level test that drives check_success/check_all through the production seams (orchestrator.py:1355/1415/1509, each passing run_result=self.result) with a pass_context run_command criterion, and asserts the criterion did not raise ValueError and the script observed a populated context — guarding the trivial attribute-passing that every current test bypasses by calling SuccessChecker.check/check_all directly. Why not static: run_result is an optional kwarg; dropping
run_result=self.resultfrom a call site is not a type error and not grep-detectable as a defect (the call still type-checks and other callers legitimately omit it). Only a runtime assertion that the script sees non-error context proves the seam is wired. Prevents: A3-low (orchestrator pass_context wiring untested at all three call sites).
Top 5 Priority Actions
- Fix the dialog-mode stale-
weighted_scoreleak — extend_serialize_context(src/coder_eval/criteria/run_command.py:35) to also resetweighted_score=None(the one provisional field actually populated mid-dialog by_run_dialog_criteria_check) so the serialized$CODER_EVAL_CONTEXTpayload matches its documented "null — do not read" contract inevery_turn/bothsimulation mode, not just single-shot. This is the only correctness/reproducibility issue in the PR. - Add a simulation
every_turndialog-modepass_contexttest (tests/test_pass_context.py) that runs ≥2 turns and asserts the serialized context'sweighted_scoreis null at turn N+1 regardless of turn N's score — the exact path where the leak lives and currently ships unguarded. - Correct the docs:
duration_secondsis documented asnull(src/coder_eval/models/criteria.py:236anddocs/TASK_DEFINITION_GUIDE.md) but is a non-nullablefloatdefaulting to0.0; change both to say0.0(not finalized) so script authors don't testis None. - Add one orchestrator-level test covering the three
run_result=self.resultwiring seams (src/coder_eval/orchestrator.py:1355 / 1415 / 1509) so a dropped kwarg can't silently make everypass_contextcriterion raiseValueErrorand score 0.0 in production with no failing test. - State the PATH-change nightly blast radius explicitly — the prepend→append of
<sandbox>/node_modules/.binin_build_run_command_env(src/coder_eval/sandbox.py) alters binary resolution on the production/nightly scoring path; confirm no nightly task relies onnode_modules/.binshadowing. Consider adopting the proposed CE026 lint rule anchoring the_serialize_contextscrub to a single canonicalPROVISIONAL_FIELDSconstant to prevent the scrub/doc-drift class outright.
Stats: 0 🔴 · 0 🟠 · 1 🟡 · 4 🔵 across 8 axes reviewed.

What
Adds an opt-in
pass_context: trueto therun_commandsuccess criterion. When set, the criterion serializes the in-flightEvaluationResult(thetask.jsonschema) to a temp file and points the scoring command at it via$CODER_EVAL_CONTEXT— giving scoring scripts the agent trajectory and the resolved task config, not just the sandbox filesystem.The decisive payoff: script authors can develop offline against a real
task.jsonfrom a previous run (zero agent runs, zero API cost), then wire the same script in unchanged.How
extra_envonSandbox.run_command— additional env vars layered last-wins onto the built environment, guarded so it cannot clobber the sandbox-isolation floor (PATH/VIRTUAL_ENV/NODE_PATH/NPM_CONFIG_PREFIX). Also corrects a staleRaises: subprocess.TimeoutExpireddocstring (the method returns(-1, "", msg)on timeout).Orchestrator→SuccessChecker→CheckContext.run_result→RunCommandChecker._check_impl's signature is unchanged; the new state rides onCheckContext._serialize_contextforce-clearssuccess_criteria_resultsvia a non-mutatingmodel_copy, so scripts can't depend on their position in the YAML or leak prior-turn verdicts (simulationevery_turnmode). The temp dir is deleted on every exit path including the timeout return, and a missingrun_resultfails closed toscore=0.0.node_modules/.bin(agent-writable) is now appended, not prepended, toPATH, so an agent that plants an interpreter shim (python/node/sh/uv) there can no longer hijack a scoring command's interpreter.Notes
tsc) present in bothnode_modules/.binand on the host/venvPATHnow resolves to the host binary instead of the sandbox-local one. A suite-wide spike found zero tasks undertasks/that depend on this ordering.experiments/default.yaml, ormodels/__init__.pychanges (pass_contextis a criterion field, not a merge root).docs/TASK_DEFINITION_GUIDE.md(field-table row + worked example + thesuccess_criteria_results-always-empty / provisional-fields rules) andCLAUDE.md.Testing
extra="forbid"parity, checker-tier (env injection, payload round-trip, emptysuccess_criteria_results, non-mutation, fail-closed, timeout cleanup), end-to-end integration through a real tempdirSandbox, and sandbox security (interpreter-shim exploit regression + package-local-CLI compatibility + PATH ordering +extra_envprotected-key guard).make verifyclean (format / check / pyright / pytest / custom CExxx lint).🤖 Generated with Claude Code